在 Tauri 应用中引入 Sidecar 的实践

本文主要介绍了如何在 Tauri 项目中引入 Node.js 或 Python 作为 Sidecar。

为什么要使用 Sidecar?

一些场景,尤其是服务端开放和数据库操作,使用 Rust 的开发效率远没有使用 Node.js 或 Python 高,且 Rust 本身的特性决定了它不太适合开发应用原型。

公共操作

引入 shell plugin

npm tauri add shell

Node.js Sidecar

由于 Nodejs 官方的 SEA 还不成熟,yao-pkg 已被废弃,nexe 要使用较新的 Nodejs 版本需要自行编译,于是我选择最朴素的方法:直接从官方发布的 Nodejs 构建好的包中提取出 node.exe 可执行文件,再在构建时将 Nodejs 部分的代码打包成单 javascript 文件,在 Tauri 应用运行时调用 node.exe + javascript bundle 文件路径来执行。

下载 Nodejs 构建包并提取 node.exe

import { createWriteStream, promises as fs } from 'node:fs';
import { createGunzip } from 'node:zlib';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import https from 'node:https';
import tar from 'tar';
import AdmZip from 'adm-zip';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

/**
 * 解析命令行参数
 * @returns {Object} 解析后的参数对象
 */
function parseArgs() {
  const args = process.argv.slice(2);
  const options = {
    version: 'v25.0.0',
    platform: undefined,
    arch: undefined,
  };

  for (let i = 0; i < args.length; i++) {
    const arg = args[i];
    
    switch (arg) {
      case '--version':
      case '-v':
        options.version = args[++i];
        break;
      case '--platform':
      case '-p':
        options.platform = args[++i];
        break;
      case '--arch':
      case '-a':
        options.arch = args[++i];
        break;
      default:
        if (arg.startsWith('--')) {
          console.error(`Unknown option: ${arg}`);
          process.exit(1);
        }
        break;
    }
  }

  return options;
}

/**
 * 根据平台和架构获取下载 URL
 * @param {string} version - Node.js 版本,如 'v25.0.0'
 * @param {string} platform - 操作系统:'win32', 'darwin', 'linux', 'aix'
 * @param {string} arch - 架构:'x64', 'arm64', 'ppc64', 'ppc64le', 's390x'
 * @returns {Object} - { url, filename, executableName }
 */
function getDownloadInfo(version, platform, arch) {
  const baseUrl = `https://nodejs.org/dist/${version}/`;
  
  let filename;
  let executableName = 'node';
  
  if (platform === 'win32') {
    executableName = 'node.exe';
    filename = `node-${version}-win-${arch}.zip`;
  } else if (platform === 'darwin') {
    filename = `node-${version}-darwin-${arch}.tar.gz`;
  } else if (platform === 'linux') {
    filename = `node-${version}-linux-${arch}.tar.gz`;
  } else if (platform === 'aix') {
    filename = `node-${version}-aix-${arch}.tar.gz`;
  } else {
    throw new Error(`Unsupported platform: ${platform}`);
  }
  
  return {
    url: baseUrl + filename,
    filename,
    executableName
  };
}

/**
 * 下载文件
 * @param {string} url - 下载地址
 * @param {string} destination - 保存路径
 */
async function downloadFile(url, destination) {
  console.log(`Downloading from: ${url}`);
  
  return new Promise((resolve, reject) => {
    https.get(url, (response) => {
      // 处理重定向
      if (response.statusCode === 302 || response.statusCode === 301) {
        downloadFile(response.headers.location, destination)
          .then(resolve)
          .catch(reject);
        return;
      }
      
      if (response.statusCode !== 200) {
        reject(new Error(`Failed to download: ${response.statusCode}`));
        return;
      }
      
      const fileStream = createWriteStream(destination);
      const totalSize = parseInt(response.headers['content-length'], 10);
      let downloadedSize = 0;
      
      response.on('data', (chunk) => {
        downloadedSize += chunk.length;
        const progress = ((downloadedSize / totalSize) * 100).toFixed(2);
        process.stdout.write(`\rDownload progress: ${progress}%`);
      });
      
      response.pipe(fileStream);
      
      fileStream.on('finish', () => {
        fileStream.close();
        console.log('\nDownload completed!');
        resolve();
      });
      
      fileStream.on('error', (err) => {
        fs.unlink(destination);
        reject(err);
      });
    }).on('error', reject);
  });
}

/**
 * 解压 tar.gz 文件并提取 node 可执行文件
 * @param {string} archivePath - 压缩包路径
 * @param {string} outputDir - 输出目录
 * @param {string} executableName - 可执行文件名
 */
async function extractTarGz(archivePath, outputDir, executableName) {
  console.log('Extracting tar.gz archive...');

  await tar.x({
    cwd: outputDir,
    file: archivePath,
    filter: (path) => {
      // 只提取 bin/node 文件
      return path.endsWith(`bin/${executableName}`)
    },
    strip: 2,
    onentry: (entry) => console.log(`Extracting: ${entry.path}`)
  });
  return join(outputDir, executableName);
}

/**
 * 解压 zip 文件并提取 node.exe
 * @param {string} archivePath - 压缩包路径
 * @param {string} outputDir - 输出目录
 * @param {string} executableName - 可执行文件名
 */
async function extractZip(archivePath, outputDir, executableName) {
  console.log('Extracting zip archive...');
  
  const zip = new AdmZip(archivePath);
  const zipEntries = zip.getEntries();
  
  for (const entry of zipEntries) {
    if (entry.entryName.endsWith(executableName)) {
      console.log(`Extracting: ${entry.entryName}`);
      
      // 直接提取到目标位置
      const targetPath = join(outputDir, executableName);
      const content = entry.getData();
      await fs.writeFile(targetPath, content);
      
      console.log(`Extracted to: ${targetPath}`);
      return targetPath;
    }
  }
  
  throw new Error('Node executable not found in archive');
}

/**
 * 主函数
 * @param {Object} options - 配置选项
 * @param {string} options.version - Node.js 版本
 * @param {string} options.platform - 操作系统
 * @param {string} options.arch - 架构
 * @param {string} options.outputDir - 输出目录
 */
async function downloadAndExtractNode(options) {
  const {
    version = 'v25.0.0',
    platform = process.platform,
    arch = process.arch,
    outputDir = join(__dirname, 'node-binaries')
  } = options;
  
  try {
    // 创建输出目录
    await fs.mkdir(outputDir, { recursive: true });
    
    // 获取下载信息
    const { url, filename, executableName } = getDownloadInfo(version, platform, arch);
    const archivePath = join(outputDir, filename);
    
    // 下载文件
    await downloadFile(url, archivePath);
    
    // 解压文件
    let finalPath;
    if (filename.endsWith('.zip')) {
      finalPath = await extractZip(archivePath, outputDir, executableName);
    } else if (filename.endsWith('.tar.gz')) {
      finalPath = await extractTarGz(archivePath, outputDir, executableName);
    }
    
    // 删除压缩包
    await fs.unlink(archivePath);
    console.log('Archive deleted.');
    
    // 设置执行权限 (Unix-like 系统)
    if (platform !== 'win32' && finalPath) {
      await fs.chmod(finalPath, 0o755);
    }
    
    console.log(`\n✓ Node executable extracted to: ${finalPath}`);
    return finalPath;
    
  } catch (error) {
    console.error('Error:', error.message);
    throw error;
  }
}


const options = parseArgs();
try {
  await downloadAndExtractNode({
    // version: 'v25.0.0',
    // platform: 'darwin',  // 可选:'win32', 'darwin', 'linux', 'aix'
    // arch: 'x64',        // 可选:'x64', 'arm64', 'ppc64', 'ppc64le', 's390x'
    ...options,
    outputDir: join(__dirname, '../node-bin')
  }).catch(console.error);
} catch (error) {
  console.error('Error:', error.message);
  process.exit(1);
}

将可执行文件移至 Tauri 目录下的 bin/ 目录:

import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { dirname } from 'node:path';

const ext = process.platform === 'win32' ? '.exe' : '';

let targetTriple;
const targetIndex = process.argv.indexOf('--target');
if (targetIndex !== -1 && process.argv[targetIndex + 1]) {
  // prefer to use the target triple passed in
  targetTriple = process.argv[targetIndex + 1];
} else {
  const rustInfo = execSync('rustc -vV');
  targetTriple = /host: (\S+)/g.exec(rustInfo)[1];
  if (!targetTriple) {
    console.error('Failed to determine platform target triple');
  }
}

const dest = `src-tauri/bin/node_runtime-${targetTriple}${ext}`;
fs.mkdirSync(dirname(dest), { recursive: true });
fs.renameSync(`node-bin/node${ext}`, dest);

将 nodejs 部分构建成单 JavaScript 文件:

esbuild src/index.ts --bundle --platform=node --format=esm --outfile=../src-tauri/bin/server.mjs

tauri.conf.json 中的 bundle 部分添加配置:

    "resources": ["./bin/*.js"],
    "externalBin": ["./bin/node_runtime"]
相关脚本

Python Sidecar

虽然官方推荐使用 pyinstaller + one file 模式打包,但是这种构建方式构建出来的单可执行文件实际上是自解压文件,在运行时会将自身解压到临时目录下执行,所以启动速度往往偏慢。

我尝试了 pyinstaller、nuitka 这两种方案(其实我也尝试过 PyOxidizer,不过这个项目已经不再维护,所以不考虑使用)。

其中 nuitka 的 standalone 模式下会将可执行文件本身和依赖文件(.pyd, .dll 等)都平铺在输出目录下,由于 Tauri 执行 Sidecar 的机制的限制,必须将可执行文件本身放在 tauri.conf.json 种指定的 Sidecar 的位置,再将其余的依赖文件都放在 tauri.conf.json 的同级下,会导致 src-tauri 目录很混乱。

而 pyinstaller 会将所有依赖文件放在输出目录的 _internal 目录下,这样在 tauri.conf.json 中只需配置:

"resources": ["./_internal/"],

即可,更方便。

import sys
import os
import subprocess
import re
import shutil
from pathlib import Path

def get_rustc_host() -> str:
    try:
        out = subprocess.check_output(['rustc', '-vV'], stderr=subprocess.STDOUT)
        text = out.decode('utf-8', errors='replace')
    except (subprocess.CalledProcessError, FileNotFoundError) as e:
        print(f'Failed to run rustc: {e}', file=sys.stderr)
        sys.exit(1)

    m = re.search(r'host:\s+(\S+)', text)
    if not m:
        print('Failed to determine platform target triple', file=sys.stderr)
        sys.exit(1)
    target = m.group(1)
    return target

def main():
    ext = '.exe' if os.name == 'nt' else ''

    # 查找 --target 参数
    target = None
    if '--target' in sys.argv:
        idx = sys.argv.index('--target')
        if idx + 1 < len(sys.argv):
            target = sys.argv[idx + 1]

    # 如果未指定,则从 rustc -vV 输出中解析 host
    if not target:
        target = get_rustc_host()

    # move executable file
    dest_executable = Path(f"src-tauri/bin/server-{target}{ext}")
    dest_executable_parent = dest_executable.parent
    dest_executable_parent.mkdir(parents=True, exist_ok=True)

    src_executable = Path(f"src-server/dist/main/main{ext}")
    if not src_executable.exists():
        print(f"Source file not found: {src_executable}", file=sys.stderr)
        sys.exit(1)

    try:
        shutil.move(str(src_executable), str(dest_executable))
    except Exception as e:
        print(f"Failed to move {src_executable} -> {dest_executable}: {e}", file=sys.stderr)
        sys.exit(1)
    
    # move dependency files
    dest_dependency_dir = Path(f"src-tauri/_internal/")
    src_dependency_dir = Path(f"src-server/dist/main/_internal/")
    if not src_dependency_dir.exists():
        print(f"Source directory not found: {src_dependency_dir}", file=sys.stderr)
        sys.exit(1)
        
    try:
        shutil.move(str(src_dependency_dir), str(dest_dependency_dir))
    except Exception as e:
        print(f"Failed to move {src_dependency_dir} -> {dest_dependency_dir}: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == '__main__':
    main()
附上将 pyinstaller 输出的文件复制并重命名到 Tauri 目录下的脚本

点此查看原文